fix(objectql): the boot-time governance audit resolves a SCOPED metadata plane the way a request does - #16610
Conversation
…a request does
`ObjectQLPlugin.runGovernanceInventory` acquired its metadata plane with
`ctx.getService('metadata')`, which reads only the synchronous service maps.
A `ServiceLifecycle.SCOPED` registration mints into `PluginLoader.scopedServices`,
so the call threw `Service 'metadata' is async - use await` before any read
method ran, the wiring swallowed the throw into "no metadata plane at all", and
the audit reported that scope's declarations as absent.
Resolve it in the router's own order instead — `ctx.getServiceScoped('metadata',
environmentId)` first, then the synchronous lookup — so the audit holds the same
instance `HttpDispatcher.resolveService` hands the router.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…d its engine double The fixture composes a SCOPED `metadata` service on a real ObjectKernel and asserts the audit REPORTS that scope's declarations — the acceptance bar the card names. Ablation (restore the synchronous read): 3 of its 5 cases go red. `scripts/engine-double-contract.pinned.json` learns the file's `findOne` double, written by `check-engine-double-contract --write` as the gate prescribed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 138b2e58ab0ae2d53b4b7b20db78a702551f1671 && git checkout 138b2e58ab0ae2d53b4b7b20db78a702551f1671
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 434ca2d64d2a82d368fc671e05e90dc1716e05bd 8651eff7ca1a07d749e7cd201ca43ee6b9153ea5 && git checkout -B drift-repro 434ca2d64d2a82d368fc671e05e90dc1716e05bd && git merge --no-ff 8651eff7ca1a07d749e7cd201ca43ee6b9153ea5
node scripts/docs-audit/affected-docs.mjs --json 434ca2d64d2a82d368fc671e05e90dc1716e05bd
|
Fixes #15252
The C4 cell #14423's ruling left open: let the boot-time, non-request-scoped
action-governance audit obtain the scoped metadata service the way a request
would.
What was broken
ObjectQLPlugin.runGovernanceInventoryacquired its metadata plane withctx.getService('metadata'). That accessor reads only the two synchronousservice maps — the kernel's own
services, andPluginLoader.getServiceInstanceover
serviceInstances. AServiceLifecycle.SCOPEDregistration mints itsinstances into
scopedServices, keyed by scope id, so the call threwService 'metadata' is async - use awaitbeforeloadMany,loadManyKeyed,loadDiagnosedorloadcould run. The wiring swallowed the throw into "nometadata plane at all", and the ADR-0110 D5 inventory reported that scope's
actiondeclarations as absent — silently, because an empty declaration set isindistinguishable from a plane that holds nothing. On such a kernel a handler the
router dispatches was reported as "registered handler with NO declaration …
REFUSED at dispatch".
The router never had that gap:
HttpDispatcher.resolveServiceasksdefaultKernel.getServiceAsync(name, scopeId)first, with the request'senvironment id, and only then falls back to the synchronous accessors.
The fix, and why this shape
The card offered two shapes and did not choose: an awaited resolution, or an
injected reader the plugin resolves once. The deciding question it names is
which one makes the audit read the same instance the router resolves through.
Taken: the awaited resolution.
ObjectQLPlugin.resolveGovernanceMetadataServicemirrors the router's own order —
ctx.getServiceScoped('metadata', environmentId)first, then the synchronous lookup. Both
ctx.getServiceScoped(name, scopeId)andthe dispatcher's
getServiceAsync(name, scopeId)land onPluginLoader.getService, which caches per scope, so the audit holds the veryobject the router dispatches from — asserted by
toBeidentity in the pin, notby equality.
Why the injected reader was not taken. Resolving once and holding the result
is the wrong lifecycle for this seam on two counts.
runGovernanceInventoryrunsat
kernel:readyand again on everymetadata:reloaded, so a reader capturedearlier freezes the identity the audit reads while the kernel's own can move
(
ObjectKernel.clearScopereleases a scope's instances). And resolving before thescope exists mints an instance beside the request path rather than the one on
it — precisely the identity failure the card is about. The awaited resolution asks
the same registry, at the same moment, that the router asks.
The pin, and its ablation
The card names its own acceptance bar: "Pin with a fixture that composes a scoped
metadata service and asserts the audit reports its declarations; ablation: the
synchronous read, the fixture goes red."
packages/objectql/src/plugin-governance-scoped-metadata.test.tscomposes a realObjectKernelwith aServiceLifecycle.SCOPEDmetadatafactory over a realMetadataManager+DatabaseLoader, captures the kernel's realPluginContext(so the accessor under test is the real one, not a transcription),and runs the audit. Five cases: the declaration reported by name, the accusation
cleared, the instance identity, a regression guard for the statically registered
plane every shipped composition uses, and the boundary that remains.
Every assertion reads the audit's output. The pre-fix failure is silent, so
"no error was thrown" is exactly the state that already existed and pins nothing.
Ablation — the synchronous read restored (
packages/objectql/src/plugin.tsrestored to the branch point, which is the pre-fix spelling; on-disk blob
proven equal to that commit's blob, marker hits 1 → 0, synchronous-read hits
0 → 1, then rebuilt):
The two cases that stay green under the ablation are the two that must: the
static-plane regression guard and the declared boundary. A "fix" that merely
silenced the audit would flip those too.
One measurement worth recording rather than smoothing over:
scripts/ablation-dist-preflight.mjs --absentexited 1 on the mutate leg,reporting the marker surviving in
dist/index.d.tsanddist/index.d.mtswhileboth executable artifacts (
dist/index.js,dist/index.mjs) were correctly freeof it. That is the marker-uniqueness false red the script's own header documents:
the surviving hits are the prose in
action-governance.ts's docblock, which namesObjectQLPlugin.resolveGovernanceMetadataServiceand was not part of themutation. It does not bear on the ablation's colour either way — this fixture
resolves its subject from source (
./plugin.js, same package), never throughdist/— and the load-bearing proof is the on-disk blob identity above.Recorded facts re-derived, not inherited
ServiceLifecycle.SCOPEDregistration outside tests is inpackages/core/examples/kernel-features-example.ts, andmetadataisregistered as a static instance at
packages/metadata/src/plugin.ts:374. Thefixture therefore composes the scoped case deliberately.
loadManywhile the router's isloadby name, andunboundDeclarationsstill reads two sources where the undeclared-handler half now reads three #14423's fix — still true after fix(metadata,objectql): the action audit reads the store key and asks the plane by name, and listNames gains loadMany fault parity #15378 landed.loadManyKeyed,loadDiagnosedand the by-name rung are all wired off the onegetServicecall, inside the onetrythat swallowed its throw.getService('metadata')no longer appears as a live call because the call sitecarries a type argument. The live acquisition was at
packages/objectql/src/plugin.ts:2517on the branch point, insiderunGovernanceInventory. Premise still valid; only the grep had rotted.Verification
pnpm --filter @objectstack/objectql test— 285 files, 4806 tests, all pass.pnpm --filter @objectstack/objectql typecheck— pass; the test-typecheck debtledger is unmoved (44 files / 242 errors / 69 pinned signatures).
node scripts/pm/dispatch-gates.mjs --commandsfrom theactual change set and reconciled with
--ran: 65 derived, 65 run, 0NOT-MEASURED, 0 UNRUN. 63 green. Two answered
PREREQUISITE NOT MET(exit 3,measured nothing, not a finding):
check:dual-build-cjs-loadsandcheck:type-check-debtboth require a whole-workspace build that CI's lint jobperforms before the step.
check:engine-split-ratiofirst refused on theshallow clone (exit 2); after
git fetch --shallow-sinceit answered (98.1%).check:engine-double-contractasked for the fixture'sfindOnedouble to berecorded and was answered with its own
--write; the row landed in thepinned ledger, not the shrink-only baseline.
Acceptance notes
packages/runtime/src/action-governance-scope-divergence.test.ts's C4 case isstill green and its assertions stay true (they exercise the kernel's raw
synchronous accessor, not the plugin), but its prose now says the opposite of
this tree: "the audit's lookup cannot see a scoped instance at all, and
plugin.tsswallows the throw", "the assertions below pin the BOUNDARY", and"reaching a request-scoped instance from there is a different change with its
own product decision, tracked on its own card" — that card is this one. Left
untouched deliberately: the dispatch fenced this PR to
packages/objectql/src/plugin.ts/action-governance.tsplus a fixture pin,with "stop on breach". A reader who trusts that docblock could revert this
change, so it wants a follow-up in the runtime lane. The canonical statement of
what is now closed and what remains lives in
action-governance.ts's updateddocblock.
kernel serving several environments at once declares no single
environmentId,so a boot-time audit has no scope to name. Auditing per environment is a
different inventory with a different lifecycle.
Clause-②re-derived from the actual diff: no. Nothing exported moves — thenew method is private on an already-exported class — and no accept set widens.
Changeset disposition derived from the act: a behaviour fix in a released
package, so a
patchchangeset for@objectstack/objectql, notskip-changeset.Generated by Claude Code